Unique Constraint를 비즈니스 규칙으로 활용하기
Unique Constraint를 비즈니스 규칙으로 활용하기
애플리케이션의 사전 중복 조회는 친절한 오류를 만드는 데는 유용하지만 동시 요청 사이의 경쟁을 막지 못한다. “동일한 공급자의 이메일은 한 번만 가입할 수 있다”처럼 항상 지켜져야 하는 규칙은 Unique Constraint로 표현하고, 제약 위반을 정상적인 비즈니스 결과로 변환해야 한다.
목차
- #문제가 되는 상황
- #SELECT 후 INSERT가 안전하지 않은 이유
- #Unique Constraint는 쓰기의 최종 판정자다
- #단일 열보다 복합 유일성이 필요한 경우
- #대소문자와 정규화 규칙을 먼저 정한다
- #NULL이 포함된 유일성은 의도와 다를 수 있다
- #논리 삭제와 값 재사용 정책
- #제약 조건 위반을 API 오류로 바꾸기
- #INSERT IGNORE와 UPSERT를 신중하게 사용한다
- #운영 테이블에 제약을 추가하는 순서
- #Unique Constraint로 표현하기 어려운 규칙
- #결론
- #관련 노트
문제가 되는 상황
회원 가입 API에서 이메일 중복을 확인하는 코드는 보통 다음처럼 시작한다. 아래 코드는 동작 설명을 위해 만든 예시이며 실제 프로젝트 코드를 가져온 것이 아니다.
async function signUp(input: SignUpInput) {
const existing = await userRepository.findByEmail(input.email);
if (existing) {
throw new EmailAlreadyUsedError();
}
return userRepository.insert({
email: input.email,
displayName: input.displayName,
});
}
한 요청씩 실행하면 잘 동작한다. 하지만 같은 이메일로 두 요청이 동시에 들어오면 둘 다 조회 시점에는 행이 없다고 판단할 수 있다. 그 뒤 두 INSERT가 차례로 커밋되면 중복 계정이 생긴다.
SELECT 후 INSERT가 안전하지 않은 이유
중복 조회와 INSERT는 서로 다른 SQL 문이다. 두 문장 사이에는 다른 트랜잭션이 끼어들 수 있는 경쟁 구간이 있다.
sequenceDiagram
participant A as 가입 요청 A
participant DB as Database
participant B as 가입 요청 B
A->>DB: SELECT email
DB-->>A: 없음
B->>DB: SELECT email
DB-->>B: 없음
A->>DB: INSERT user
B->>DB: INSERT user
Note over A,B: 제약이 없으면 두 행 모두 저장트랜잭션으로 두 문장을 묶어도 일반적인 격리 수준에서는 자동으로 안전해지지 않는다. “없음”을 읽은 두 트랜잭션이 모두 삽입을 시도할 수 있기 때문이다. 잠금과 높은 격리 수준으로 직렬화할 수도 있지만, 값 하나의 유일성을 보장하려는 목적이라면 DB가 제공하는 유니크 제약이 훨씬 직접적이다.
입력 중 이메일 사용 여부를 빠르게 안내하는 UX에는 사전 조회가 유용하다. 다만 그 결과는 잠시 뒤 바뀔 수 있으므로 최종 보장은 INSERT 시점의 제약 조건이 담당해야 한다.
Unique Constraint는 쓰기의 최종 판정자다
이메일이 테이블 전체에서 유일해야 한다면 스키마에 규칙을 선언한다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(320) NOT NULL,
display_name VARCHAR(100) NOT NULL,
created_at DATETIME(6) NOT NULL,
CONSTRAINT uq_users_email UNIQUE (email)
);
혹은 기존 테이블에 유니크 인덱스를 추가할 수 있다.
CREATE UNIQUE INDEX uq_users_email
ON users(email);
동시 INSERT가 들어오더라도 DB는 동일한 유니크 키를 가진 두 행을 모두 커밋시키지 않는다. 하나가 성공하면 다른 하나는 제약 조건 위반으로 실패한다.
요청 A INSERT ── 성공 ── COMMIT
요청 B INSERT ── unique violation
이 보장은 API 서버뿐 아니라 배치, 관리자 콘솔, SQL 스크립트 등 테이블에 쓰는 모든 경로에 적용된다. 그래서 제약 조건은 애플리케이션 검증의 대체재라기보다 여러 쓰기 경로 아래에 놓이는 마지막 방어선이다.
단일 열보다 복합 유일성이 필요한 경우
실제 규칙은 “이메일은 전 세계에서 하나”가 아닐 수 있다. 소셜 로그인 공급자별로 같은 이메일을 허용한다면 (provider, email) 조합이 유일해야 한다.
CREATE UNIQUE INDEX uq_users_provider_email
ON users(provider, email);
| provider | 허용 여부 | |
|---|---|---|
google |
dev@example.com |
첫 행이므로 허용 |
github |
dev@example.com |
provider가 달라 허용 |
google |
dev@example.com |
조합이 같아 거부 |
다른 예로 한 사용자가 이벤트마다 한 번만 응모할 수 있다면 (event_id, member_id)가 자연스러운 후보가 된다.
CREATE UNIQUE INDEX uq_event_entries_event_member
ON event_entries(event_id, member_id);
unique_1보다 uq_event_entries_event_member처럼 대상과 열을 알 수 있는 이름이 운영 오류 분석과 마이그레이션에 유리하다. 애플리케이션이 제약 이름으로 오류를 분기할 때도 의도가 선명하다.
대소문자와 정규화 규칙을 먼저 정한다
Dev@example.com과 dev@example.com을 같은 값으로 볼지는 단순한 SQL 문제가 아니라 제품 정책이다. DB의 collation이 대소문자를 구분하지 않으면 둘은 같은 유니크 키로 처리될 수 있고, binary 또는 case-sensitive 비교라면 다른 값으로 저장될 수 있다.
애플리케이션에서 저장 전에 정규화하는 방식도 있다.
function normalizeEmail(raw: string): string {
return raw.trim().toLowerCase();
}
INSERT INTO users(email, normalized_email, display_name)
VALUES (:original_email, :normalized_email, :display_name);
CREATE UNIQUE INDEX uq_users_normalized_email
ON users(normalized_email);
원본 표기와 비교용 값을 분리하면 화면에는 사용자가 입력한 표기를 유지하면서 일관된 유일성 규칙을 적용할 수 있다. 다만 이메일 주소의 모든 부분을 임의로 변형하는 것은 공급자별 의미 차이를 만들 수 있으므로, 어떤 정규화를 지원하는지 명시해야 한다.
문자열뿐 아니라 전화번호, 사업자 번호, 외부 시스템 ID도 마찬가지다. 공백, 하이픈, 국가 코드가 다른 표현을 같은 식별자로 볼지 먼저 정한 뒤 정규화 열과 유니크 제약을 설계한다.
NULL이 포함된 유일성은 의도와 다를 수 있다
많은 관계형 DB에서 NULL은 “알 수 없음”을 뜻하며 서로 같다고 판정되지 않는다. 따라서 nullable 열에 유니크 제약을 둬도 여러 NULL 행이 허용될 수 있다.
CREATE TABLE profiles (
id BIGINT PRIMARY KEY,
external_handle VARCHAR(100) NULL,
CONSTRAINT uq_profiles_external_handle UNIQUE (external_handle)
);
id=1, external_handle=NULL → 허용
id=2, external_handle=NULL → DB에 따라 허용
“값이 입력된 경우에는 유일하면 된다”는 요구라면 이 동작이 적합하다. 반대로 “미입력 상태도 한 행만 허용”이라는 특수한 요구라면 단순 유니크 제약만으로 표현되지 않을 수 있다. 대상 DB의 NULL 및 부분 인덱스 지원 방식을 확인해야 한다.
복합 유니크 키에서도 nullable 열이 하나 포함되면 예상치 못한 중복을 허용할 수 있다.
UNIQUE (tenant_id, optional_code)
optional_code가 NULL인 행을 tenant마다 하나만 허용하려는 규칙이라면 별도 상태 열, 생성 열, 표현식 인덱스 등 DB에 맞는 설계가 필요하다. 먼저 NULL은 값이 아니라 알 수 없음이다의 의미를 기준으로 모델링해야 한다.
논리 삭제와 값 재사용 정책
논리 삭제를 쓰는 테이블에서는 “삭제된 사용자의 이메일로 다시 가입할 수 있는가?”를 결정해야 한다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(320) NOT NULL,
deleted_at DATETIME(6) NULL,
CONSTRAINT uq_users_email UNIQUE (email)
);
위 구조는 삭제 여부와 상관없이 이메일을 영구 점유한다. 보안과 감사 요구 때문에 재사용을 막으려는 서비스라면 의도에 맞다. 재가입을 허용하려면 다음 선택지를 비교해야 한다.
| 방식 | 장점 | 주의점 |
|---|---|---|
| 삭제 시 이메일을 익명 값으로 변경 | 단순한 유니크 제약 유지 | 원본 식별자 보존 정책 필요 |
| 활성 행만 대상으로 한 부분 유니크 인덱스 | 규칙을 직접 표현 | DB별 지원 차이 |
| 활성 상태를 복합 키에 포함 | 구현이 쉬울 수 있음 | 삭제 행이 여러 개일 때 또 충돌 가능 |
| 새 계정 대신 기존 계정 복구 | 사용자 이력 연결 | 인증과 복구 정책이 복잡해짐 |
예를 들어 부분 인덱스를 지원하는 DB라면 의도를 다음처럼 표현할 수 있다.
CREATE UNIQUE INDEX uq_active_users_email
ON users(email)
WHERE deleted_at IS NULL;
이 문법을 모든 DB가 동일하게 지원하는 것은 아니므로 실제 대상 DB에서 대안을 확인해야 한다. 중요한 것은 deleted_at 열을 추가한 뒤 우연히 결정되는 동작에 맡기지 않고 제품 정책을 먼저 정하는 것이다.
제약 조건 위반을 API 오류로 바꾸기
유니크 충돌은 동시 요청에서 정상적으로 발생할 수 있는 비즈니스 결과다. DB 오류 메시지를 그대로 500 응답으로 내보내지 말고 안정적인 도메인 오류로 변환한다.
async function createEntry(input: CreateEntryInput) {
try {
return await entryRepository.insert(input);
} catch (error) {
if (isUniqueViolation(error, "uq_event_entries_event_member")) {
throw new AlreadyEnteredEventError(input.eventId);
}
throw error;
}
}
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"code": "EVENT_ALREADY_ENTERED",
"message": "이미 응모한 이벤트입니다."
}
드라이버별 구조는 다르지만 가능하면 오류 코드와 제약 이름을 사용한다. 오류 메시지 문자열의 전체 문장을 파싱하면 DB 버전, 언어 설정, 드라이버 변경에 취약하다. 또한 원본 DB 오류에는 테이블명이나 입력값이 포함될 수 있으므로 그대로 클라이언트에 노출하지 않는다.
INSERT IGNORE와 UPSERT를 신중하게 사용한다
충돌 처리를 간단히 하려고 INSERT IGNORE나 UPSERT를 사용할 수 있지만, 의미를 먼저 확인해야 한다.
INSERT INTO event_entries(event_id, member_id, created_at)
VALUES (:event_id, :member_id, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE member_id = member_id;
이 쿼리는 중복 요청을 성공처럼 처리하는 멱등 API에는 어울릴 수 있다. 반면 “이미 존재함”을 사용자에게 명확히 알려야 한다면 충돌을 숨기는 것이 잘못이다. INSERT IGNORE는 유니크 충돌 외 다른 경고까지 조용히 처리할 가능성이 있어 더 주의해야 한다.
UPSERT에서 어떤 유니크 제약이 충돌했는지도 중요하다. 한 테이블에 uq_email, uq_username이 모두 있는데 모든 충돌을 같은 UPDATE로 처리하면 전혀 다른 사용자의 행을 갱신할 수 있다. 편의 문법을 쓰기 전에 충돌 대상과 원하는 도메인 결과를 명확히 한다.
운영 테이블에 제약을 추가하는 순서
기존 테이블에 유니크 제약을 바로 추가하면 이미 중복 데이터가 있을 때 마이그레이션이 실패한다. 먼저 중복 그룹을 찾는다.
SELECT normalized_email, COUNT(*) AS duplicate_count
FROM users
GROUP BY normalized_email
HAVING COUNT(*) > 1;
복합 규칙이라면 모든 키를 같은 grain으로 묶는다.
SELECT provider, normalized_email, COUNT(*) AS duplicate_count
FROM users
GROUP BY provider, normalized_email
HAVING COUNT(*) > 1;
안전한 적용 순서는 대체로 다음과 같다.
- 앞으로 사용할 정규화 및 유일성 규칙을 확정한다.
- 쓰기 코드가 새 규칙에 맞는 값을 저장하도록 먼저 배포한다.
- 기존 데이터의 중복을 조회하고 병합·삭제·격리 정책을 정한다.
- 운영 데이터 정리를 실행하고 재검증한다.
- 락과 소요 시간을 고려한 방식으로 유니크 인덱스를 생성한다.
- 제약 위반을 도메인 오류로 바꾸는 코드를 활성화한다.
- 충돌률과 예상하지 못한 쓰기 경로를 관측한다.
DB 버전과 DDL 방식에 따라 메타데이터 잠금, 추가 저장 공간, 복제 지연이 생길 수 있다. 트래픽이 낮은 시간대, 온라인 DDL 지원, 롤백 계획을 확인해야 한다.
Unique Constraint로 표현하기 어려운 규칙
유니크 제약은 한 행의 열 조합이 다른 행과 중복되지 않는 규칙에 강하다. 하지만 다음 규칙은 단순 유니크 키만으로 표현하기 어렵다.
- 한 사용자는 진행 중인 예약을 최대 3개만 가질 수 있다.
- 같은 회의실 예약 시간은 서로 겹치면 안 된다.
- 팀별 관리자 수는 최소 한 명이어야 한다.
- 전체 재고 예약량의 합은 상품 재고를 넘을 수 없다.
이런 규칙에는 조건부 인덱스, 배타 제약, 잠금과 집계, 직렬화 가능한 트랜잭션, 별도 카운터 모델 등이 필요할 수 있다. 유니크 제약으로 억지로 인코딩하기보다 불변식이 어떤 행과 범위에 걸쳐 있는지 먼저 분석한다.
결론
Unique Constraint는 단순한 성능용 인덱스가 아니라 동시 요청과 여러 쓰기 경로에서도 비즈니스 규칙을 지키는 데이터 무결성 장치다. 사전 중복 조회는 사용자 경험을 개선하지만 최종 보장이 될 수 없으며, INSERT 시점의 제약 위반을 예상 가능한 도메인 결과로 처리해야 한다.
제약을 설계할 때는 열 하나만 볼 것이 아니라 실제 유일성 범위, 문자열 정규화, collation, NULL, 논리 삭제 후 재사용 정책을 함께 결정해야 한다. 기존 운영 테이블이라면 중복 데이터를 먼저 정리하고 DDL의 잠금과 복제 영향을 계획한다. DB가 지킬 수 있는 규칙은 DB에 선언하되, 애플리케이션은 더 친절한 검증과 오류 표현을 담당하는 것이 두 계층의 역할을 가장 잘 활용하는 방법이다.